# -----------------------------------------------------------------------------
# Copyright (c) 2021- Spyder Project Contributors
#
# Released under the terms of the MIT License
# (see LICENSE.txt in the project root directory for details)
# -----------------------------------------------------------------------------

"""
Helper functions to work with the Spyder plugin API.
"""

from __future__ import annotations

from abc import ABCMeta as BaseABCMeta
import collections.abc
import sys
import typing

if sys.version_info < (3, 10):
    from typing_extensions import ParamSpec
else:
    from typing import ParamSpec  # noqa: ICN003

_P = ParamSpec("_P")
_T = typing.TypeVar("_T")


def get_class_values(cls) -> list[str]:
    """
    Get the attribute values for the class enumerations used in our API.

    Idea from `Stack Overflow <https://stackoverflow.com/a/17249228/438386>`__.

    Parameters
    ----------
    cls
        Class object to list the enumeration values of.

    Returns
    -------
    list[str]
        String attribute values from a Spyder pseudo-"enum" class.
    """
    return [v for (k, v) in cls.__dict__.items() if k[:1] != "_"]


class PrefixNode:
    """Utility class used to represent a prefixed string tuple."""

    def __init__(self, path: tuple[str, ...] | None = None) -> None:
        """
        Representation of a prefixed string tuple.

        Parameters
        ----------
        path : tuple[str, ...] | None, optional
            Underlying prefixed string tuple. The default is None.

        Returns
        -------
        None
        """
        self.children = {}
        self.path = path

    def __iter__(self):
        prefix = [((self.path,), self)]
        while prefix != []:
            current_prefix, node = prefix.pop(0)
            prefix += [
                (current_prefix + (c,), node.children[c])
                for c in node.children
            ]
            yield current_prefix

    def add_path(self, path: tuple[str, ...]) -> None:
        """
        Add a path to the prefix node.

        Parameters
        ----------
        path : tuple[str, ...]
            Underlying prefixed string tuple.

        Returns
        -------
        None
        """
        prefix, *rest = path
        if prefix not in self.children:
            self.children[prefix] = PrefixNode(prefix)

        if len(rest) > 0:
            child = self.children[prefix]
            child.add_path(rest)


class PrefixedTuple(PrefixNode):
    """Utility class to store and iterate over prefixed string tuples."""

    def __iter__(self):
        for key in self.children:
            child = self.children[key]
            for prefix in child:
                yield prefix


class classproperty(property):
    """
    Decorator to declare class constants requiring computation as properties.

    Idea from `Stack Overflow <https://stackoverflow.com/a/7864317/438386>`__.
    """

    def __get__(self, cls, owner):
        return classmethod(self.fget).__get__(None, owner)()


class DummyAttribute:
    """Dummy class to mark abstract attributes."""

    pass


def abstract_attribute(
    obj: collections.abc.Callable[_P, _T] | DummyAttribute | None = None,
) -> _T:
    """
    Decorator to mark abstract attributes.

    Must be used in conjunction with the :class:`abc.ABCMeta` metaclass.

    Parameters
    ----------
    obj: collections.abc.Callable[_P, _T] | DummyAttribute | None, optional
        The callable attribute to mark as abstract, a new instance of
        :class:`DummyAttribute` by default.

    Returns
    -------
    _T
        The result of executing the callable attribute ``obj``.
    """
    if obj is None:
        obj = DummyAttribute()
    setattr(obj, "__is_abstract_attribute__", True)
    return obj  # type: ignore


class ABCMeta(BaseABCMeta):
    """
    Metaclass to mark abstract classes.

    Adds support for abstract attributes. If a class has abstract attributes
    and is instantiated, a NotImplementedError is raised.

    Usage
    -----

    .. code-block:: python

        class MyABC(metaclass=ABCMeta):
            @abstract_attribute
            def my_abstract_attribute(self):
                pass

        class MyClassOK(MyABC):
            def __init__(self):
                self.my_abstract_attribute = 1

        class MyClassNotOK(MyABC):
            pass

    Raises
    ------
    NotImplementedError
        When it's not possible to instantiate an abstract class with abstract
        attributes.
    """

    def __call__(cls, *args, **kwargs):
        # Collect all abstract-attribute names from the entire MRO
        abstract_attr_names = set()
        for base in cls.__mro__:
            for name, value in base.__dict__.items():
                if getattr(value, "__is_abstract_attribute__", False):
                    abstract_attr_names.add(name)

        for name, value in cls.__dict__.items():
            if not getattr(value, "__is_abstract_attribute__", False):
                abstract_attr_names.discard(name)

        if abstract_attr_names:
            raise NotImplementedError(
                "Can't instantiate abstract class "
                "{} with abstract attributes: {}".format(
                    cls.__name__, ", ".join(abstract_attr_names)
                )
            )

        return super().__call__(*args, **kwargs)
